Conversation
WorldSansha
commented
Feb 28, 2026
- 新增 songTransitionMode 三选一设置(关闭/Auto Mix/Gapless),替代独立的 enableAutomix 开关
- 通过 Pinia getter 向后兼容 enableAutomix / useGaplessPlayback,现有 automix 代码无需修改
- 新增 AudioBufferPlayer + GaplessManager 实现无缝播放(预解码 AudioBuffer + 采样级精确调度)
- 提取共享 getNextSongInfo() 到 PlayerController,automix 和 gapless 复用下一曲确定逻辑
- 统一 refreshNextPreload() 入口:始终执行 URL 预取,gapless 额外触发 buffer 预解码
- 播放列表变更检测复用 automix 的懒校验模式(onTimeUpdate 中比对),不在变更方法中加判断
- 修复 BaseAudioPlayer 渐入音量闪烁和 stop() 冻结共享 AudioContext
- 修复 pause 事件在歌曲自然结束时取消 gapless 调度的时序问题
- 添加设置迁移 v12:enableAutomix → songTransitionMode
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 此拉取请求旨在通过引入无缝播放功能和统一歌曲过渡模式来显著提升音频播放体验。它将现有的自动混音功能与新的无缝播放机制整合到一个单一的设置中,简化了用户配置。核心改动包括新的音频播放器和管理器,以及对现有播放逻辑的重构和优化,以确保平滑、无缝的歌曲切换,并修复了几个与音频处理相关的播放问题。 Highlights
Changelog
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This PR introduces gapless playback functionality and unifies song transition modes. The review identified a critical security vulnerability in GaplessManager related to unvalidated URL handling, which could lead to SSRF or DoS. Additionally, several code quality and maintainability improvements were suggested for AudioBufferPlayer and GaplessManager, including addressing duplicate logic, redundant code, and encapsulation issues. All original comments have been retained as they align with best practices and are not contradicted by the provided rules.
| const response = await fetch(url, { | ||
| signal: abortController.signal, | ||
| }); | ||
|
|
||
| // 检查是否已取消 | ||
| if (abortController.signal.aborted) return; | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`HTTP ${response.status}`); | ||
| } | ||
|
|
||
| const arrayBuffer = await response.arrayBuffer(); |
There was a problem hiding this comment.
The preload method in GaplessManager accepts an unvalidated url parameter which is directly passed to the fetch API. In an Electron environment, this can lead to Server-Side Request Forgery (SSRF) or unauthorized local file access if the attacker can control the song URL (e.g., via a malicious playlist). Furthermore, since the entire response is loaded into memory using response.arrayBuffer(), an attacker could provide a URL to a very large file to cause a Denial of Service (DoS) by exhausting the renderer process's memory.
Recommendation:
- Validate the
urlparameter to ensure it uses allowed protocols (e.g.,http:,https:) and points to trusted domains. - Check the
Content-Lengthheader of the response before callingresponse.arrayBuffer()to ensure the file size is within reasonable limits.
| export class AudioBufferPlayer extends BaseAudioPlayer { | ||
| /** 预解码的音频缓冲区 */ | ||
| private buffer: AudioBuffer | null = null; | ||
| /** 当前活动的 SourceNode */ | ||
| private sourceNode: AudioBufferSourceNode | null = null; | ||
| /** 是否处于暂停状态 */ | ||
| private _paused = true; | ||
| /** 播放速率 */ | ||
| private _rate = 1.0; | ||
|
|
||
| /** 锚点偏移量(秒) */ | ||
| private anchorOffset = 0; | ||
| /** 锚点时刻的 AudioContext 时间 */ | ||
| private anchorContextTime = 0; | ||
|
|
||
| /** timeupdate 定时器 */ | ||
| private timeupdateTimer: ReturnType<typeof setInterval> | null = null; | ||
|
|
||
| /** 引擎能力描述 */ | ||
| public override readonly capabilities: EngineCapabilities = { | ||
| supportsRate: true, | ||
| supportsSinkId: false, | ||
| supportsEqualizer: true, | ||
| supportsSpectrum: true, | ||
| }; | ||
|
|
||
| constructor() { | ||
| super(); | ||
| } | ||
|
|
||
| /** | ||
| * 注入预解码的 AudioBuffer | ||
| */ | ||
| public setBuffer(buffer: AudioBuffer) { | ||
| this.buffer = buffer; | ||
| } | ||
|
|
||
| // 音频图谱初始化回调(无需创建 MediaElement) | ||
| protected onGraphInitialized(): void { | ||
| // 空实现 | ||
| } | ||
|
|
||
| // AudioBufferPlayer 不支持 URL 加载 | ||
| public async load(_url: string): Promise<void> { | ||
| // 空实现,buffer 通过 setBuffer 注入 | ||
| } | ||
|
|
||
| /** | ||
| * 创建并启动 SourceNode | ||
| */ | ||
| protected async doPlay(): Promise<void> { | ||
| if (!this.buffer || !this.audioCtx || !this.inputNode) return; | ||
|
|
||
| // 清理旧的 source | ||
| this.stopSource(); | ||
|
|
||
| const source = this.audioCtx.createBufferSource(); | ||
| source.buffer = this.buffer; | ||
| source.playbackRate.value = this._rate; | ||
| source.connect(this.inputNode); | ||
|
|
||
| // 记录锚点 | ||
| this.anchorContextTime = this.audioCtx.currentTime; | ||
| source.start(0, this.anchorOffset); | ||
|
|
||
| source.onended = () => { | ||
| if (this.sourceNode === source && !this._paused) { | ||
| // 检查是否真正播放完毕 | ||
| const elapsed = this.currentTime; | ||
| const dur = this.duration; | ||
| if (dur > 0 && elapsed < dur - 0.5) { | ||
| console.warn( | ||
| `[AudioBufferPlayer] source.onended 提前触发 (elapsed=${elapsed.toFixed(2)}, duration=${dur.toFixed(2)})`, | ||
| ); | ||
| return; | ||
| } | ||
| this._paused = true; | ||
| this.stopTimeupdateTimer(); | ||
| this.dispatch(AUDIO_EVENTS.ENDED); | ||
| } | ||
| }; | ||
|
|
||
| this.sourceNode = source; | ||
| this._paused = false; | ||
| this.startTimeupdateTimer(); | ||
| this.dispatch(AUDIO_EVENTS.PLAY); | ||
| } | ||
|
|
||
| /** | ||
| * 精确调度播放(无缝衔接时使用) | ||
| * @param offset 音频偏移量(秒) | ||
| * @param when AudioContext 时间点 | ||
| */ | ||
| public scheduleStart(offset: number, when: number) { | ||
| if (!this.buffer || !this.audioCtx || !this.inputNode) return; | ||
|
|
||
| this.stopSource(); | ||
|
|
||
| const source = this.audioCtx.createBufferSource(); | ||
| source.buffer = this.buffer; | ||
| source.playbackRate.value = this._rate; | ||
| source.connect(this.inputNode); | ||
|
|
||
| this.anchorOffset = offset; | ||
| this.anchorContextTime = when; | ||
| source.start(when, offset); | ||
|
|
||
| source.onended = () => { | ||
| if (this.sourceNode === source && !this._paused) { | ||
| const elapsed = this.currentTime; | ||
| const dur = this.duration; | ||
| if (dur > 0 && elapsed < dur - 0.5) { | ||
| console.warn( | ||
| `[AudioBufferPlayer] scheduled source.onended 提前触发 (elapsed=${elapsed.toFixed(2)}, duration=${dur.toFixed(2)})`, | ||
| ); | ||
| return; | ||
| } | ||
| this._paused = true; | ||
| this.stopTimeupdateTimer(); | ||
| this.dispatch(AUDIO_EVENTS.ENDED); | ||
| } | ||
| }; | ||
|
|
||
| this.sourceNode = source; | ||
| this._paused = false; | ||
| this.startTimeupdateTimer(); | ||
| } | ||
|
|
||
| protected doPause(): void { | ||
| if (this._paused) return; | ||
| // 记录当前位置 | ||
| this.anchorOffset = this.currentTime; | ||
| this.stopSource(); | ||
| this._paused = true; | ||
| this.stopTimeupdateTimer(); | ||
| this.dispatch(AUDIO_EVENTS.PAUSE); | ||
| } | ||
|
|
||
| protected doSeek(time: number): void { | ||
| this.anchorOffset = Math.max(0, Math.min(time, this.duration)); | ||
| if (this.audioCtx) { | ||
| this.anchorContextTime = this.audioCtx.currentTime; | ||
| } | ||
|
|
||
| // 如果正在播放,重新创建 source | ||
| if (!this._paused) { | ||
| this.stopSource(); | ||
|
|
||
| if (this.buffer && this.audioCtx && this.inputNode) { | ||
| const source = this.audioCtx.createBufferSource(); | ||
| source.buffer = this.buffer; | ||
| source.playbackRate.value = this._rate; | ||
| source.connect(this.inputNode); | ||
| source.start(0, this.anchorOffset); | ||
|
|
||
| source.onended = () => { | ||
| if (this.sourceNode === source && !this._paused) { | ||
| const elapsed = this.currentTime; | ||
| const dur = this.duration; | ||
| if (dur > 0 && elapsed < dur - 0.5) return; | ||
| this._paused = true; | ||
| this.stopTimeupdateTimer(); | ||
| this.dispatch(AUDIO_EVENTS.ENDED); | ||
| } | ||
| }; | ||
|
|
||
| this.sourceNode = source; | ||
| this.anchorContextTime = this.audioCtx.currentTime; | ||
| } | ||
| } | ||
|
|
||
| this.dispatch(AUDIO_EVENTS.SEEKED); | ||
| } | ||
|
|
||
| public setRate(value: number): void { | ||
| const old = this._rate; | ||
| this._rate = value; | ||
|
|
||
| // 更新锚点以保持位置准确 | ||
| if (this.audioCtx && !this._paused) { | ||
| this.anchorOffset = this.currentTime; | ||
| this.anchorContextTime = this.audioCtx.currentTime; | ||
| } | ||
|
|
||
| if (this.sourceNode) { | ||
| this.sourceNode.playbackRate.value = value; | ||
| } | ||
|
|
||
| // 速率变化后需要重新计算锚点 | ||
| if (old !== value && this.audioCtx && !this._paused) { | ||
| this.anchorContextTime = this.audioCtx.currentTime; | ||
| } | ||
| } | ||
|
|
||
| public getRate(): number { | ||
| return this._rate; | ||
| } | ||
|
|
||
| protected async doSetSinkId(_deviceId: string): Promise<void> { | ||
| // AudioBufferPlayer 不支持独立设备切换,依赖共享 AudioContext | ||
| } | ||
|
|
||
| public get src(): string { | ||
| return ""; | ||
| } | ||
|
|
||
| public get duration(): number { | ||
| return this.buffer?.duration ?? 0; | ||
| } | ||
|
|
||
| public get currentTime(): number { | ||
| if (this._paused || !this.audioCtx) return this.anchorOffset; | ||
| const wallDelta = this.audioCtx.currentTime - this.anchorContextTime; | ||
| return Math.max(0, Math.min(this.anchorOffset + wallDelta * this._rate, this.duration)); | ||
| } | ||
|
|
||
| public get paused(): boolean { | ||
| return this._paused; | ||
| } | ||
|
|
||
| public getErrorCode(): number { | ||
| return 0; | ||
| } | ||
|
|
||
| /** | ||
| * 销毁引擎,释放内存 | ||
| */ | ||
| public override destroy(): void { | ||
| this.stopSource(); | ||
| this.stopTimeupdateTimer(); | ||
| this.buffer = null; | ||
| this._paused = true; | ||
| super.destroy(); | ||
| } | ||
|
|
||
| /** 停止当前 SourceNode */ | ||
| private stopSource() { | ||
| if (this.sourceNode) { | ||
| try { | ||
| this.sourceNode.onended = null; | ||
| this.sourceNode.stop(); | ||
| this.sourceNode.disconnect(); | ||
| } catch { | ||
| // 可能已经停止 | ||
| } | ||
| this.sourceNode = null; | ||
| } | ||
| } | ||
|
|
||
| /** 启动 timeupdate 定时器 */ | ||
| private startTimeupdateTimer() { | ||
| this.stopTimeupdateTimer(); | ||
| this.timeupdateTimer = setInterval(() => { | ||
| if (!this._paused) { | ||
| this.dispatch(AUDIO_EVENTS.TIME_UPDATE); | ||
| } | ||
| }, 200); | ||
| } | ||
|
|
||
| /** 停止 timeupdate 定时器 */ | ||
| private stopTimeupdateTimer() { | ||
| if (this.timeupdateTimer) { | ||
| clearInterval(this.timeupdateTimer); | ||
| this.timeupdateTimer = null; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
AudioBufferPlayer 的实现中有一些可以改进的地方,以提高代码的可维护性和清晰度:
- 重复的
onended逻辑:在doPlay、scheduleStart和doSeek方法中,创建AudioBufferSourceNode和设置其onended回调的逻辑存在重复。此外,doSeek方法中的onended回调缺少了对事件提前触发的警告日志。建议将这部分逻辑提取到一个私有辅助方法中。 setRate方法中冗余代码:在setRate方法的末尾,anchorContextTime被重复赋值。if (old !== value && this.audioCtx && !this._paused)这个判断块可以移除,因为anchorContextTime在前面已经被更新。
通过重构可以使代码更简洁且易于维护。
src/core/gapless/GaplessManager.ts
Outdated
| // 直接操作 gainNode.gain 设置音量,不调用 setVolume 避免污染 volume 字段 | ||
| if (this.player["gainNode"]) { | ||
| const gainNode = this.player["gainNode"] as GainNode; | ||
| gainNode.gain.setValueAtTime(volume, when); | ||
| } |
There was a problem hiding this comment.
在 schedule 方法中,通过 this.player["gainNode"] 的方式访问了 AudioBufferPlayer 的受保护(protected)成员 gainNode。这种方式破坏了类的封装性。
建议在 BaseAudioPlayer 或 AudioBufferPlayer 中添加一个公共方法来处理音量的定时调度,例如 scheduleVolumeAtTime(volume, when)。这样可以使 GaplessManager 的实现更清晰,并保持 AudioBufferPlayer 的封装。
例如,在 BaseAudioPlayer 中添加:
public scheduleVolumeAtTime(value: number, when: number) {
if (this.gainNode && this.audioCtx) {
// 考虑 replayGain
const targetValue = value * this.replayGain;
this.gainNode.gain.setValueAtTime(targetValue, when);
}
}然后在 GaplessManager 中调用 this.player.scheduleVolumeAtTime(volume, when)。
- 新增 songTransitionMode 三选一设置(关闭/Auto Mix/Gapless),替代独立的 enableAutomix 开关 - 通过 Pinia getter 向后兼容 enableAutomix / useGaplessPlayback,现有 automix 代码无需修改 - 新增 AudioBufferPlayer + GaplessManager 实现无缝播放(预解码 AudioBuffer + 采样级精确调度) - 提取共享 getNextSongInfo() 到 PlayerController,automix 和 gapless 复用下一曲确定逻辑 - 统一 refreshNextPreload() 入口:始终执行 URL 预取,gapless 额外触发 buffer 预解码 - 播放列表变更检测复用 automix 的懒校验模式(onTimeUpdate 中比对),不在变更方法中加判断 - 修复 BaseAudioPlayer 渐入音量闪烁和 stop() 冻结共享 AudioContext - 修复 pause 事件在歌曲自然结束时取消 gapless 调度的时序问题 - 添加设置迁移 v12:enableAutomix → songTransitionMode Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
prefetchNextSong() 用 playIndex+1 不处理 DJ 跳过,导致与 getNextSongInfo() 返回不同歌曲,ID 校验失败使 gapless 预载被跳过。 改为直接用 getAudioSource(nextInfo.song) 获取正确的 URL。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7723910 to
262bcac
Compare
- 将 doPlay、scheduleStart、doSeek 中重复的 SourceNode 创建与 onended 回调提取为 createAndStartSource 辅助方法 - 修复 doSeek 的 onended 回调缺失提前触发警告日志的问题 - 修复 setRate 中 anchorOffset 使用新速率计算的错误,改为先用旧速率 计算当前位置再更新 _rate - 移除 setRate 末尾冗余的 anchorContextTime 赋值 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
forceIf 仅在 UI 层强制显示,不会修改 store 中的实际值, 导致关闭"下一首预载"后无缝播放的 AudioBuffer 预解码被完全跳过 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- nextOrPrev 循环跳过 DJ 歌后在 playSong 中无法再次命中检测, 改为在循环中收集被跳过的歌名并统一弹出提示 - 右键"下一首播放"添加 DJ 歌时提前警告用户该歌曲将被跳过 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- GaplessManager 增加 songId 字段和 updateNextIndex 方法 - 懒校验用 songId 判断歌曲是否变化,索引变化但歌曲相同时 只更新索引不重新预载(节省带宽和解码时间) - handleGaplessSwitch 取不到歌时回退到标准切歌 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
本次 PR 实现了无缝播放功能,并将其与自动混音(Auto Mix)功能合并,统一为“切歌过渡模式”设置。这是一个重要的功能增强,代码实现质量很高。主要变更包括:新增 songTransitionMode 设置项;通过 AudioBufferPlayer 和 GaplessManager 实现无缝播放;重构 PlayerController 以统一预加载逻辑;修复 BaseAudioPlayer 中的一些问题;并添加了必要的设置迁移。代码结构清晰,对边界情况和向后兼容性考虑周全。我只发现一处可以优化代码重复的地方,具体见文件评论。
| if (v === "automix") { | ||
| window.$dialog.warning({ | ||
| title: "启用自动混音 (Beta)", | ||
| content: | ||
| "可能出现兼容性问题,该功能在早期测试,遇到问题请反馈issue,不保证可以及时处理。效果可能因为歌曲而异,保守策略。", | ||
| positiveText: "开启", | ||
| negativeText: "取消", | ||
| onPositiveClick: () => { | ||
| settingStore.enableAutomix = true; | ||
| settingStore.songTransitionMode = "automix"; | ||
| }, | ||
| }); | ||
| } else if (v === "gapless") { | ||
| window.$dialog.warning({ | ||
| title: "启用无缝播放 (Beta)", | ||
| content: | ||
| "无缝播放会预解码下一首歌曲的音频数据,每首歌曲约占用 50-150MB 内存。如果设备内存较小,可能影响性能。该功能目前处于预览状态,有任何问题请提交 Issues。", | ||
| positiveText: "开启", | ||
| negativeText: "取消", | ||
| onPositiveClick: () => { | ||
| settingStore.songTransitionMode = "gapless"; | ||
| }, | ||
| }); | ||
| } else { | ||
| settingStore.enableAutomix = v; | ||
| settingStore.songTransitionMode = v; | ||
| } |
There was a problem hiding this comment.
为了减少代码重复并提高可维护性,可以对 automix 和 gapless 的处理逻辑进行合并。这两个分支的结构几乎完全相同,只是标题和内容不同。
if (v === "automix" || v === "gapless") {
const titles = {
automix: "启用自动混音 (Beta)",
gapless: "启用无缝播放 (Beta)",
};
const contents = {
automix:
"可能出现兼容性问题,该功能在早期测试,遇到问题请反馈issue,不保证可以及时处理。效果可能因为歌曲而异,保守策略。",
gapless:
"无缝播放会预解码下一首歌曲的音频数据,每首歌曲约占用 50-150MB 内存。如果设备内存较小,可能影响性能。该功能目前处于预览状态,有任何问题请提交 Issues。",
};
window.$dialog.warning({
title: titles[v],
content: contents[v],
positiveText: "开启",
negativeText: "取消",
onPositiveClick: () => {
settingStore.songTransitionMode = v;
},
});
} else {
settingStore.songTransitionMode = v;
}Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
setupSongUI 未重置 statusStore.duration,切歌时滑块 max 残留上一首时长, 用户拖到超出新歌时长的位置后 seek 到末尾触发 ended 导致跳过。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>